<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Query;

use BadMethodCallException;
use Doctrine\ODM\MongoDB\Aggregation\Stage\Sort;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Iterator\CachingIterator;
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
use Doctrine\ODM\MongoDB\Iterator\Iterator;
use Doctrine\ODM\MongoDB\Iterator\PrimingIterator;
use Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
use Doctrine\ODM\MongoDB\MongoDBException;
use Doctrine\ODM\MongoDB\UnitOfWork;
use InvalidArgumentException;
use IteratorAggregate;
use MongoDB\Collection;
use MongoDB\DeleteResult;
use MongoDB\Driver\ReadPreference;
use MongoDB\InsertOneResult;
use MongoDB\Operation\FindOneAndUpdate;
use MongoDB\UpdateResult;
use Traversable;
use UnexpectedValueException;

use function array_combine;
use function array_filter;
use function array_flip;
use function array_intersect_key;
use function array_key_first;
use function array_keys;
use function array_map;
use function array_merge;
use function array_values;
use function is_array;
use function is_callable;
use function is_string;

/**
 * ODM Query wraps the raw Doctrine MongoDB queries to add additional functionality
 * and to hydrate the raw arrays of data to Doctrine document objects.
 *
 * @phpstan-type QueryShape array{
 *     distinct?: string,
 *     hint?: string|array<string, -1|1>,
 *     limit?: int,
 *     maxTimeMS?: int,
 *     multiple?: bool,
 *     new?: bool,
 *     newObj?: array<string, mixed>,
 *     query?: array<string, mixed>,
 *     readPreference?: ReadPreference,
 *     select?: array<string, 0|1|array<string, mixed>>,
 *     skip?: int,
 *     snapshot?: bool,
 *     sort?: array<string, -1|1|SortMeta>,
 *     type: Query::TYPE_*,
 *     upsert?: bool,
 * }
 * @phpstan-import-type Hints from UnitOfWork
 * @phpstan-import-type SortMeta from Sort
 */
final class Query implements IteratorAggregate
{
    public const TYPE_FIND            = 1;
    public const TYPE_FIND_AND_UPDATE = 2;
    public const TYPE_FIND_AND_REMOVE = 3;
    public const TYPE_INSERT          = 4;
    public const TYPE_UPDATE          = 5;
    public const TYPE_REMOVE          = 6;
    public const TYPE_DISTINCT        = 9;
    public const TYPE_COUNT           = 11;

    // 2 was used for HINT_SLAVE_OKAY, which was removed in 2.0
    public const HINT_REFRESH         = 1;
    public const HINT_READ_PREFERENCE = 3;
    public const HINT_READ_ONLY       = 5;

    /**
     * The DocumentManager instance.
     */
    private DocumentManager $dm;

    /**
     * The ClassMetadata instance.
     */
    private ClassMetadata $class;

    /**
     * Whether to hydrate results as document class instances.
     */
    private bool $hydrate = true;

    /**
     * Array of primer Closure instances.
     *
     * @var array<string, callable|true|null>
     */
    private array $primers = [];

    private bool $rewindable = true;

    /**
     * Hints for UnitOfWork behavior.
     *
     * @phpstan-var Hints
     */
    private array $unitOfWorkHints = [];

    /**
     * The Collection instance.
     */
    protected Collection $collection;

    /**
     * Query structure generated by the Builder class.
     *
     * @phpstan-var QueryShape
     */
    private array $query;

    private ?Iterator $iterator = null;

    /**
     * Query options
     *
     * @var array<string, mixed>
     */
    private array $options;

    /**
     * @param QueryShape                        $query
     * @param array<string, mixed>              $options
     * @param array<string, callable|true|null> $primers
     */
    public function __construct(DocumentManager $dm, ClassMetadata $class, Collection $collection, array $query, array $options = [], bool $hydrate = true, bool $refresh = false, array $primers = [], bool $readOnly = false, bool $rewindable = true)
    {
        $primers = array_filter($primers);

        switch ($query['type']) {
            case self::TYPE_FIND:
            case self::TYPE_FIND_AND_UPDATE:
            case self::TYPE_FIND_AND_REMOVE:
            case self::TYPE_INSERT:
            case self::TYPE_UPDATE:
            case self::TYPE_REMOVE:
            case self::TYPE_DISTINCT:
            case self::TYPE_COUNT:
                break;

            default:
                throw new InvalidArgumentException('Invalid query type: ' . $query['type']);
        }

        $this->collection = $collection;
        $this->query      = $query;
        $this->options    = $options;
        $this->dm         = $dm;
        $this->class      = $class;
        $this->hydrate    = $hydrate;
        $this->primers    = $primers;

        $this->setReadOnly($readOnly);
        $this->setRefresh($refresh);
        $this->setRewindable($rewindable);

        if (! isset($query['readPreference'])) {
            return;
        }

        $this->unitOfWorkHints[self::HINT_READ_PREFERENCE] = $query['readPreference'];
    }

    public function __clone()
    {
        $this->iterator = null;
    }

    /**
     * Return an array of information about the query structure for debugging.
     *
     * The $name parameter may be used to return a specific key from the
     * internal $query array property. If omitted, the entire array will be
     * returned.
     *
     * @return array<string, mixed>|mixed
     */
    public function debug(?string $name = null)
    {
        return $name !== null ? $this->query[$name] : $this->query;
    }

    /**
     * Execute the query and returns the results.
     *
     * @return Iterator|UpdateResult|InsertOneResult|DeleteResult|array<string, mixed>|object|int|null
     *
     * @throws MongoDBException
     */
    public function execute()
    {
        $results = $this->runQuery();

        if (! $this->hydrate) {
            return $results;
        }

        $uow = $this->dm->getUnitOfWork();

        /* If a single document is returned from a findAndModify command and it
         * includes the identifier field, attempt hydration.
         */
        if (
            ($this->query['type'] === self::TYPE_FIND_AND_UPDATE ||
                $this->query['type'] === self::TYPE_FIND_AND_REMOVE) &&
            is_array($results) && isset($results['_id'])
        ) {
            $results = $uow->getOrCreateDocument($this->class->name, $results, $this->unitOfWorkHints);

            if (! empty($this->primers)) {
                $referencePrimer = new ReferencePrimer($this->dm, $uow);

                foreach ($this->primers as $fieldName => $primer) {
                    $primer = is_callable($primer) ? $primer : null;
                    $referencePrimer->primeReferences($this->class, [$results], $fieldName, $this->unitOfWorkHints, $primer);
                }
            }
        }

        return $results;
    }

    /**
     * Gets the ClassMetadata instance.
     */
    public function getClass(): ClassMetadata
    {
        return $this->class;
    }

    public function getDocumentManager(): DocumentManager
    {
        return $this->dm;
    }

    /**
     * Execute the query and return its result, which must be an Iterator.
     *
     * If the query type is not expected to return an Iterator,
     * BadMethodCallException will be thrown before executing the query.
     * Otherwise, the query will be executed and UnexpectedValueException will
     * be thrown if {@link Query::execute()} does not return an Iterator.
     *
     * @throws BadMethodCallException If the query type would not return an Iterator.
     * @throws UnexpectedValueException If the query did not return an Iterator.
     * @throws MongoDBException
     */
    public function getIterator(): Iterator
    {
        switch ($this->query['type']) {
            case self::TYPE_FIND:
            case self::TYPE_DISTINCT:
                break;

            default:
                throw new BadMethodCallException('Iterator would not be returned for query type: ' . $this->query['type']);
        }

        if ($this->iterator === null) {
            $result = $this->execute();
            if (! $result instanceof Iterator) {
                throw new UnexpectedValueException('Iterator was not returned for query type: ' . $this->query['type']);
            }

            $this->iterator = $result;
        }

        return $this->iterator;
    }

    /**
     * Return the query structure.
     *
     * @return QueryShape
     */
    public function getQuery(): array
    {
        return $this->query;
    }

    /**
     * Execute the query and return the first result.
     *
     * @return array<string, mixed>|object|null
     */
    public function getSingleResult()
    {
        $clonedQuery                 = clone $this;
        $clonedQuery->query['limit'] = 1;

        return $clonedQuery->getIterator()->current() ?: null;
    }

    /**
     * Return the query type.
     */
    public function getType(): int
    {
        return $this->query['type'];
    }

    /**
     * Sets whether or not to hydrate the documents to objects.
     */
    public function setHydrate(bool $hydrate): void
    {
        $this->hydrate = $hydrate;
    }

    /**
     * Set whether documents should be registered in UnitOfWork. If document would
     * already be managed it will be left intact and new instance returned.
     *
     * This option has no effect if hydration is disabled.
     */
    public function setReadOnly(bool $readOnly): void
    {
        $this->unitOfWorkHints[self::HINT_READ_ONLY] = $readOnly;
    }

    /**
     * Set whether to refresh hydrated documents that are already in the
     * identity map.
     *
     * This option has no effect if hydration is disabled.
     */
    public function setRefresh(bool $refresh): void
    {
        $this->unitOfWorkHints[self::HINT_REFRESH] = $refresh;
    }

    /**
     * Set to enable wrapping of resulting Iterator with CachingIterator
     */
    public function setRewindable(bool $rewindable = true): void
    {
        $this->rewindable = $rewindable;
    }

    /**
     * Execute the query and return its results as an array.
     *
     * @see IteratorAggregate::toArray()
     *
     * @return mixed[]
     */
    public function toArray(): array
    {
        return $this->getIterator()->toArray();
    }

    /**
     * Returns an array containing the specified keys and their values from the
     * query array, provided they exist and are not null.
     *
     * @return array<string, mixed>
     */
    private function getQueryOptions(string ...$keys): array
    {
        return array_filter(
            array_intersect_key($this->query, array_flip($keys)),
            static fn ($value) => $value !== null,
        );
    }

    /**
     * Decorate the cursor with caching, hydration, and priming behavior.
     *
     * Note: while this method could strictly take a MongoDB\Driver\Cursor, we
     * accept Traversable for testing purposes since Cursor cannot be mocked.
     * HydratingIterator, CachingIterator, and BaseIterator expect a Traversable
     * so this should not have any adverse effects.
     *
     * @param Traversable<mixed> $cursor
     */
    private function makeIterator(Traversable $cursor): Iterator
    {
        if ($this->hydrate) {
            $cursor = new HydratingIterator($cursor, $this->dm->getUnitOfWork(), $this->class, $this->unitOfWorkHints);
        }

        $cursor = $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor);

        if (! empty($this->primers)) {
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
            $cursor          = new PrimingIterator($cursor, $this->class, $referencePrimer, $this->primers, $this->unitOfWorkHints);
        }

        return $cursor;
    }

    /**
     * Returns an array with its keys renamed based on the translation map.
     *
     * @param array<string, mixed>  $options
     * @param array<string, string> $rename
     *
     * @return array<string, mixed> $rename Translation map (from => to) for renaming keys
     */
    private function renameQueryOptions(array $options, array $rename): array
    {
        if (empty($options)) {
            return $options;
        }

        $options = array_combine(
            array_map(
                static fn ($key) => $rename[$key] ?? $key,
                array_keys($options),
            ),
            array_values($options),
        );

        return $options;
    }

    /**
     * Execute the query and return its result.
     *
     * The return value will vary based on the query type. Commands with results
     * (e.g. aggregate, inline mapReduce) may return an ArrayIterator. Other
     * commands and operations may return a status array or a boolean, depending
     * on the driver's write concern. Queries and some mapReduce commands will
     * return an Iterator.
     *
     * @return Iterator|UpdateResult|InsertOneResult|DeleteResult|array<string, mixed>|object|int|null
     */
    private function runQuery()
    {
        $options = $this->options;

        switch ($this->query['type']) {
            case self::TYPE_FIND:
                $queryOptions = $this->getQueryOptions('select', 'sort', 'skip', 'limit', 'readPreference', 'hint');
                $queryOptions = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);

                $cursor = $this->collection->find(
                    $this->query['query'],
                    array_merge($options, $queryOptions),
                );

                return $this->makeIterator($cursor);

            case self::TYPE_FIND_AND_UPDATE:
                $queryOptions                   = $this->getQueryOptions('select', 'sort', 'upsert');
                $queryOptions                   = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
                $queryOptions['returnDocument'] = $this->query['new'] ?? false ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER : FindOneAndUpdate::RETURN_DOCUMENT_BEFORE;

                $operation = $this->isFirstKeyUpdateOperator() ? 'findOneAndUpdate' : 'findOneAndReplace';

                return $this->collection->{$operation}(
                    $this->query['query'],
                    $this->query['newObj'],
                    array_merge($options, $queryOptions)
                );

            case self::TYPE_FIND_AND_REMOVE:
                $queryOptions = $this->getQueryOptions('select', 'sort');
                $queryOptions = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);

                return $this->collection->findOneAndDelete(
                    $this->query['query'],
                    array_merge($options, $queryOptions),
                );

            case self::TYPE_INSERT:
                return $this->collection->insertOne($this->query['newObj'], $options);

            case self::TYPE_UPDATE:
                $multiple = $this->query['multiple'] ?? false;

                if ($this->isFirstKeyUpdateOperator()) {
                    $operation = 'updateOne';
                } else {
                    if ($multiple) {
                        throw new InvalidArgumentException('Combining the "multiple" option without using an update operator as first operation in a query is not supported.');
                    }

                    $operation = 'replaceOne';
                }

                if ($multiple) {
                    return $this->collection->updateMany(
                        $this->query['query'],
                        $this->query['newObj'],
                        array_merge($options, $this->getQueryOptions('upsert')),
                    );
                }

                return $this->collection->{$operation}(
                    $this->query['query'],
                    $this->query['newObj'],
                    array_merge($options, $this->getQueryOptions('upsert'))
                );

            case self::TYPE_REMOVE:
                return $this->collection->deleteMany($this->query['query'], $options);

            case self::TYPE_DISTINCT:
                $collection = $this->collection;
                $query      = $this->query;

                return $collection->distinct(
                    $query['distinct'],
                    $query['query'],
                    array_merge($options, $this->getQueryOptions('readPreference')),
                );

            case self::TYPE_COUNT:
                $collection = $this->collection;
                $query      = $this->query;

                return $collection->count(
                    $query['query'],
                    array_merge($options, $this->getQueryOptions('hint', 'limit', 'skip', 'readPreference')),
                );

            default:
                throw new InvalidArgumentException('Invalid query type: ' . $this->query['type']);
        }
    }

    private function isFirstKeyUpdateOperator(): bool
    {
        $firstKey = array_key_first($this->query['newObj']);

        return is_string($firstKey) && $firstKey[0] === '$';
    }
}
